"""
Copyright © 2025 Walkline Wang (https://walkline.wang)
Gitee: https://gitee.com/walkline/bemfa-cloud-sdk-for-micropython
"""
__version__ = '0.0.3'
__version_info__ = (0, 0, 3)
print('module bemfa cloud tcpclient version:', __version__)


from errno import EINPROGRESS, ENOTCONN, ECONNRESET, ECONNABORTED, ETIMEDOUT
from machine import RTC
import select
import socket
import time
import re


#region Commands
COMMAND_SUBSCRIBE = 1
'''订阅消息，当设备发送一次此消息类型，之后就可以收到发往该主题的消息'''

COMMAND_PUBLISH = 2
'''发布消息，向订阅该主题的设备发送消息'''

COMMAND_SUBSCRIBE_AND_PULL = 3
'''订阅消息，和 COMMAND_SUBSCRIBE 相同，并且会拉取一次已发送过的消息'''

COMMAND_GET_DATETIME = 7
'''获取时间，获取当前北京时间'''

COMMAND_GET_LAST_MESSAGE = 9
'''遗嘱消息，拉取一次已经发送的消息'''

COMMAND_PING = 0
'''心跳包，保持连接'''

COMMAND_INTERNET_FAULT = 10
'''网络故障'''
#endregion

#region Datetime Types
TYPE_DATETIME = 1
'''获取当前日期和时间，例如：2021-06-11 17:20:54'''

TYPE_TIME = 2
'''获取当前时间，例如：17:20:54'''

TYPE_TIMESTAMP = 3
'''获取当前时间戳，例如：1623403325'''
#endregion

#region Publish Methods
METHOD_NORMAL = 0
'''包括发布者在内的所有订阅者都会收到该消息'''

METHOD_SET = 1
'''除发布者外的所有订阅者都会收到该消息'''

METHOD_UP = 2
'''只更新云端数据不推送消息'''

METHODS_MAP = {
	METHOD_NORMAL: '',
	METHOD_SET: '/set',
	METHOD_UP: '/up',
}
#endregion

ENDING = '\r\n'
'''消息结尾'''

BEMFA_TCP_SERVER = 'bemfa.com'
BEMFA_TCP_PORT = 8344

def printf(title, msg, *args, **kwargs):
	now = time.localtime()
	datetime = f'[{now[0]}-{now[1]:02d}-{now[2]:02d} {now[3]:02d}:{now[4]:02d}:{now[5]:02d}]'
	print(f'\033[0;34m{datetime}\033[1;37m{title}\033[0m {msg}', *args, **kwargs)


class BemfaTCPClient(object):
	def __init__(self, host: str = BEMFA_TCP_SERVER, port: int = BEMFA_TCP_PORT, *, secret: str, result_cb: callable):
		self.__host = host
		self.__port = port
		self.__secret = secret
		self.__result_cb = result_cb

		self.__poller = select.poll()
		self.__socket = None

		self.__topics = set()
		self.__history = {}
		self.__wifi_connected = False
		self.__debug = False

	def deinit(self):
		if self.__socket:
			self.__poller.unregister(self.__socket)
			self.__socket.close()
			self.__socket = None


	#region Methods
	def connect(self):
		'''连接到巴法云服务器'''
		self.deinit()

		try:
			sock_addr = socket.getaddrinfo(self.__host, self.__port)[0][-1]
			self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
			self.__connect(sock_addr)
		except OSError:
			self.__wifi_connected = False

			if self.__result_cb:
				self.__result_cb({'cmd': COMMAND_INTERNET_FAULT})

	def subscribe(self, topics: str | tuple | list):
		'''
		订阅指定的主题

		`topics`: 要订阅的主题，多个主题用逗号分隔或使用元组列表传递，单次最多订阅八个主题
		'''
		self.__setup_topics(topics)

		params   = {'topic': ','.join(self.topics)}
		query    = self.__prepare_query_string(COMMAND_SUBSCRIBE, params)
		received = self.__send_and_receive(query)

		if self.__debug:
			printf('[sub]', received)
			# [sub] b'cmd=1&res=1\r\n'

		self.__parse_responses(received)

	def subscribe_and_pull(self, topic: str):
		'''
		订阅指定的主题，并获取一条历史消息

		`topic`: 要订阅的主题
		'''
		self.__setup_topics(topic)

		params   = {'topic': topic}
		query    = self.__prepare_query_string(COMMAND_SUBSCRIBE_AND_PULL, params)
		received = self.__send_and_receive(query)

		if self.__debug:
			printf('[sub&pull]', received)
			# [sub&pull] b'cmd=3&uid=xxx&topic=xxx&msg=hello world\r\n'

		self.__parse_responses(received)

	def publish(self, topic: str, msg, method: int = METHOD_SET):
		'''
		向已订阅的主题发布消息

		`topic`: 已订阅的主题
		`msg`: 消息内容
		`method`: 发布消息的方式，默认为`METHOD_SET`
		'''
		params = {
			'topic': f'{topic}{METHODS_MAP[method]}',
			'msg': msg,
		}

		query    = self.__prepare_query_string(COMMAND_PUBLISH, params)
		received = self.__send_and_receive(query)

		if self.__debug:
			printf('[pub]', received)
			# [pub] b'topic=xxx&msg=hello world&uid=xxx&cmd=2\r\ncmd=2&res=1\r\n'
			# [pub] b'cmd=2&res=1\r\n'

		self.__parse_responses(received)

	def check_message(self, topic: str = None):
		'''
		获取已订阅主题最后一条历史消息

		`topic`: 如果不为`None`则获取指定主题的消息，否则获取所有已订阅主题的消息
		'''
		topics = self.topics

		if topic is not None:
			topics = [topic]

		for topic in topics:
			params   = {'topic': topic}
			query    = self.__prepare_query_string(COMMAND_GET_LAST_MESSAGE, params)
			received = self.__send_and_receive(query)

			if self.__debug:
				printf('[last_msg]', received)
				# [last_msg] b'cmd=9&time=1735808186&uid=xxx&topic=xxx&msg=hello world\r\n'

			self.__parse_responses(received)

	def get_datetime(self):
		'''获取当前时间，并设置本地 RTC 时钟'''
		params   = {'type': TYPE_TIMESTAMP}
		query    = self.__prepare_query_string(COMMAND_GET_DATETIME, params)
		received = self.__send_and_receive(query)

		try:
			# https://blog.csdn.net/jd3096/article/details/120612539
			tim = time.gmtime(int(received.decode()) - 946656000)
			RTC().datetime((tim[0], tim[1], tim[2], tim[6] + 1, tim[3], tim[4], tim[5], 0))
			now = time.localtime()

			if self.__debug:
				printf('[datetime]', f'{now[0]}-{now[1]:02d}-{now[2]:02d} {now[3]:02d}:{now[4]:02d}:{now[5]:02d}')
		except ValueError:
			if self.__debug:
				printf('[datetime]', received)

			self.__parse_responses(received)

	def ping(self):
		'''发送心跳包'''
		query    = f'{COMMAND_PING}{ENDING}'
		received = self.__send_and_receive(query)

		if self.__debug:
			printf('[ping]', received)
			# [ping] b'cmd=0&res=1\r\n'

		self.__parse_responses(received)
	#endregion


	#region Nonblocking Methods
	# https://github.com/micropython/micropython/blob/master/examples/network/https_client_nonblocking.py
	def __connect(self, addr):
		self.__socket.setblocking(False)

		try:
			self.__socket.connect(addr)
		except OSError as ose:
			if ose.errno != EINPROGRESS:
				raise ose

		self.__poller.register(self.__socket)
		start_time = time.time()
		timeout = 3

		while True:
			elapsed_time = time.time() - start_time

			if elapsed_time >= timeout:
				raise OSError(ETIMEDOUT)

			events = self.__poller.poll(10)

			if not events:
				continue

			for _, event in events:
				if event & select.POLLOUT:
					printf('[tcp]', 'Connected to Bemfa Cloud Server')
					return

	def __write(self, data):
		self.__poller.register(self.__socket, select.POLLOUT)
		start_time = time.time()
		timeout = 3

		while True:
			elapsed_time = time.time() - start_time

			if elapsed_time >= timeout:
				raise OSError(ETIMEDOUT)

			events = self.__poller.poll(10)

			if not events:
				continue

			for _, event in events:
				if event & select.POLLOUT:
					while data:
						nbytes = self.__socket.write(data)

						if nbytes is not None:
							data = data[nbytes:]
					return
	
	def __read(self, nbytes: int) -> bytes:
		self.__poller.register(self.__socket, select.POLLIN)
		start_time = time.time()
		timeout = 3

		while True:
			elapsed_time = time.time() - start_time

			if elapsed_time >= timeout:
				raise OSError(ETIMEDOUT)

			events = self.__poller.poll(10)

			if not events:
				continue

			for _, event in events:
				if event & select.POLLIN:
					data = self.__socket.read(nbytes)
					return data or b''
	#endregion


	#region Functions
	def __setup_topics(self, topics: str | tuple | list = None):
		'''
		管理订阅主题，传递`None`清空订阅列表

		多个主题用逗号分隔或使用元组列表传递，单次最多订阅八个主题
		'''
		if not isinstance(topics, (str, tuple, list)):
			if topics is None:
				self.__topics.clear()
			return

		if isinstance(topics, str):
			topics = topics.split(',')

		self.__topics.update(topics[:8])

	def __send_and_receive(self, query: str) -> bytes:
		received = b''

		if not self.__wifi_connected:
			return received

		try:
			self.__write(query.encode())

			# b'res=' : most commands
			# b'time=': COMMAND_GET_LAST_MESSAGE
			# b'cmd=3': COMMAND_SUBSCRIBE_AND_PULL
			while b'res=' not in received and  \
				  b'time=' not in received and \
				  b'cmd=3' not in received and \
				  not received.isdigit(): # COMMAND_GET_DATETIME
				received += self.__read(1024)
		except OSError as ose:
			if ose.errno in (ENOTCONN, ECONNRESET, ECONNABORTED):
				self.__wifi_connected = False
				printf('[tcp]', 'Disconnected from Bemfa Cloud Server')

				if self.__result_cb:
					self.__result_cb({'cmd': COMMAND_INTERNET_FAULT})
			elif ose.errno == ETIMEDOUT:
				printf('[tcp]', 'Send/Receive Timeout')
			else:
				raise f'[tcp] OSError: {ose}'

		return received

	def __prepare_query_string(self, command: int, params: dict = {}) -> str:
		query = ''
		params.update({
			'cmd': command,
			'uid': self.__secret,
		})

		for key, value in params.items():
			query += f'&{key}={value}'

		return query[1:] + ENDING

	def __parse_responses(self, responses: bytes):
		if not responses:
			return

		params = {}

		for response in responses.decode().split('\r\n'):
			for prefix in ('cmd', 'topic', 'time', 'msg', 'res'):
				match = re.search(f'({prefix})=([^&]*)', response)

				try:
					params[match.group(1)] = int(match.group(2)) if prefix in ('cmd', 'res', 'time') else match.group(2)
				except (IndexError, AttributeError):
					pass

		if self.__result_cb:
			if params.get('cmd') == COMMAND_GET_LAST_MESSAGE:
				if self.__history.get(params['topic']) == params.get('msg'):
					return

				self.__history[params.get('topic')] = params.get('msg')

			self.__result_cb(params)
	#endregion


	#region Properties
	@property
	def topics(self) -> list:
		'''已订阅的主题列表'''
		return list(self.__topics)

	@property
	def history(self) -> dict:
		'''已收到的历史消息'''
		return self.__history

	@property
	def wifi_connected(self) -> bool:
		return self.__wifi_connected

	@wifi_connected.setter
	def wifi_connected(self, value: bool):
		'''
		设置/获取 Wifi 连接状态

		在实际使用中应当由外部程序控制此标志位，设置为`False`时将不再收发任何消息
		'''
		self.__wifi_connected = value

	@property
	def debug(self) -> bool:
		return self.__debug

	@debug.setter
	def debug(self, value: bool):
		'''开启/关闭调试信息'''
		self.__debug = value
	#endregion
